Skip to content

Open and accept zero reserve channels#4428

Open
tankyleo wants to merge 8 commits intolightningdevkit:mainfrom
tankyleo:2026-02-zero-reserve
Open

Open and accept zero reserve channels#4428
tankyleo wants to merge 8 commits intolightningdevkit:mainfrom
tankyleo:2026-02-zero-reserve

Conversation

@tankyleo
Copy link
Contributor

@tankyleo tankyleo commented Feb 19, 2026

Fixes #1801

@ldk-reviews-bot
Copy link

ldk-reviews-bot commented Feb 19, 2026

👋 I see @joostjager was un-assigned.
If you'd like another reviewer assignment, please click here.

@tankyleo tankyleo moved this to Goal: Merge in Weekly Goals Feb 19, 2026
@tankyleo tankyleo self-assigned this Feb 19, 2026
@tankyleo tankyleo force-pushed the 2026-02-zero-reserve branch from ffa1657 to 5fa3a7c Compare February 26, 2026 09:56
@tankyleo tankyleo marked this pull request as ready for review February 26, 2026 10:45
@tankyleo tankyleo requested a review from TheBlueMatt February 26, 2026 10:46
@tankyleo tankyleo added this to the 0.3 milestone Feb 26, 2026
@codecov
Copy link

codecov bot commented Feb 26, 2026

Codecov Report

❌ Patch coverage is 78.74818% with 146 lines in your changes missing coverage. Please review.
✅ Project coverage is 85.91%. Comparing base (14e522f) to head (5fa3a7c).
⚠️ Report is 25 commits behind head on main.

Files with missing lines Patch % Lines
lightning/src/ln/channel.rs 70.64% 121 Missing and 2 partials ⚠️
lightning/src/ln/channelmanager.rs 81.70% 15 Missing ⚠️
lightning/src/ln/functional_test_utils.rs 94.33% 6 Missing ⚠️
lightning/src/sign/tx_builder.rs 97.46% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4428      +/-   ##
==========================================
- Coverage   85.94%   85.91%   -0.03%     
==========================================
  Files         159      159              
  Lines      104644   105103     +459     
  Branches   104644   105103     +459     
==========================================
+ Hits        89934    90298     +364     
- Misses      12204    12300      +96     
+ Partials     2506     2505       -1     
Flag Coverage Δ
tests 85.91% <78.74%> (-0.03%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chanmon_consistency needs to be updated to have a 0-reserve channel or two (I believe we now have three channels between each pair of peers, so we can just do it on a subset of them, in fact we could have three separate channel types for better coverage).

/// Creates a new outbound channel to the given remote node and with the given value.
///
/// The only difference between this method and [`ChannelManager::create_channel`] is that this method sets
/// the reserve the counterparty must keep at all times in the channel to zero. This allows the counterparty to
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: If that's the only difference let's say create_channel_to_trusted_peer_0_reserve? Nice to be explicit, imo.


let channel_value_satoshis =
our_funding_contribution_sats.saturating_add(msg.common_fields.funding_satoshis);
// TODO(zero_reserve): support reading and writing the `disable_channel_reserve` field
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two questions. Shouldn't we check that if a channel has the 0-reserve feature bit and if it is fail if the user isn't accepting 0-reserve? Also why shouldn't we just set it now? I'm not sure we need to bother with a staging bit, really, honestly...

@TheBlueMatt
Copy link
Collaborator

Needs rebase now :/

@tankyleo tankyleo force-pushed the 2026-02-zero-reserve branch from 471ba8f to 253db4d Compare March 5, 2026 10:55
@tankyleo tankyleo requested a review from TheBlueMatt March 5, 2026 10:57
@tankyleo
Copy link
Contributor Author

tankyleo commented Mar 5, 2026

Needs rebase now :/

Let me know if you prefer I rebase first

@TheBlueMatt
Copy link
Collaborator

Feel free to go ahead and rebase and squash, yea.

@tankyleo tankyleo force-pushed the 2026-02-zero-reserve branch 2 times, most recently from 5fa3a7c to 43be438 Compare March 5, 2026 17:21
@tankyleo
Copy link
Contributor Author

tankyleo commented Mar 5, 2026

Squash diff (do not click compare just above, I pushed the wrong branch, and later corrected it):

https://github.com/lightningdevkit/rust-lightning/compare/253db4d9a05cda43f74c2835448126d3205b27b8..43be438f70ba28ad7918e407026a78763ac2b368

@tankyleo tankyleo force-pushed the 2026-02-zero-reserve branch from 43be438 to 7fde002 Compare March 5, 2026 17:36
@tankyleo
Copy link
Contributor Author

tankyleo commented Mar 5, 2026

git range-diff main 43be438 7fde002

1:  bfba6e993 ! 1:  b916c3596 Add inbound and outbound checks for zero reserve channels
    @@ lightning/src/ln/channel.rs: impl FundingScope {

                // New reserve values are based on the new channel value and are v2-specific
     -          let counterparty_selected_channel_reserve_satoshis =
    --                  Some(get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS));
    +-                  get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS);
     -          let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis(
     -                  post_channel_value,
     -                  context.counterparty_dust_limit_satoshis,
    @@ lightning/src/ln/channel.rs: impl FundingScope {
     +                  == 0
     +          {
     +                  // If we previously had a 0-value reserve, continue with the same reserve
    -+                  Some(0)
    ++                  0
     +          } else {
    -+                  Some(get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS))
    ++                  get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS)
     +          };
     +          let holder_selected_channel_reserve_satoshis =
     +                  if prev_funding.holder_selected_channel_reserve_satoshis == 0 {
    @@ lightning/src/sign/tx_builder.rs: fn get_next_commitment_stats(
        );

     @@ lightning/src/sign/tx_builder.rs: fn get_available_balances(
    -   let fee_spike_buffer_htlc =
                if channel_type.supports_anchor_zero_fee_commitments() { 0 } else { 1 };

    +   // Note that the feerate is 0 in zero-fee commitment channels, so this statement is a noop
     -  let local_feerate = feerate_per_kw
     +  let spiked_feerate = feerate_per_kw
                * if is_outbound_from_holder && !channel_type.supports_anchors_zero_fee_htlc_tx() {
2:  1f8ca6c66 = 2:  53fc9a354 Add 0-reserve to `accept_inbound_channel_from_trusted_peer`
3:  1b94f7cc3 = 3:  da0520818 Add `ChannelManager::create_channel_to_trusted_peer_0reserve`
4:  19b8d7943 = 4:  1905f94e7 Shakedown zero reserve channels
5:  de2366da4 = 5:  4e6a7527a Format `ChannelManager::create_channel_internal` and...
6:  43be438f7 = 6:  7fde002e7 Update `chanmon_consistency` to include 0FC and 0-reserve channels

@ldk-reviews-bot
Copy link

✅ Added second reviewer: @joostjager

@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @TheBlueMatt @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @TheBlueMatt @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 2nd Reminder

Hey @TheBlueMatt @joostjager! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@tankyleo tankyleo requested review from carlaKC and removed request for joostjager March 10, 2026 14:33
@ldk-reviews-bot
Copy link

🔔 3rd Reminder

Hey @TheBlueMatt @carlaKC! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

Copy link
Contributor

@carlaKC carlaKC left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First pass - haven't gone through the tests yet.

Comment on lines +639 to +642
let dust_limit_msat = dust_limit_satoshis.saturating_mul(1000);
if holder_balance_msat.saturating_sub(max_dust_htlc_msat) < dust_limit_msat
&& counterparty_balance_msat < dust_limit_msat
&& nondust_htlc_count == 0
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we re-use check_no_outputs here? Could also return early if we don't need any action.

We'd need to change it to a saturating_sub, but I think that's okay because we have a checked_sub check of our balances on 321 after calling check_no_outputs anyway?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done below thank you !

Comment on lines +660 to +665
// 1) The dust_limit_satoshis plus the fee of the exisiting commitment at the spiked feerate.
// 2) The fee of the commitment with an additional non-dust HTLC, aka the fee spike buffer HTLC.
// In this case we don't mind the holder balance output dropping below the dust limit, as
// this additional non-dust HTLC will create the single remaining output on the commitment.
let min_balance_msat =
cmp::max(dust_limit_satoshis + tx_fee_sat, fee_spike_buffer_sat) * 1000;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment for (1) says "at the spiked feerate" but isn't tx_fee_sat is just our current feerate?

A more descriptive name would be helpful here (if you can thing of something less gross than max_output_preserving_fee?).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment for (1) says "at the spiked feerate" but isn't tx_fee_sat is just our current feerate?

See the callsites of the function; we always use the spiked_feerate as the feerate_per_kw parameter. I admit this is confusing, should I rename the function parameter to spiked_feerate ?

A more descriptive name would be helpful here (if you can thing of something less gross than max_output_preserving_fee?).

How is current_spiked_tx_fee_sat ? It would be consistent with the spiked_feerate style. With max_output_preserving_fee you want to highlight that we want to maintain an output on the commitment transaction even with a 2x increase in the feerate ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

went ahead with both variable renames below

/// If it does not confirm before we decide to close the channel, or if the funding transaction
/// does not pay to the correct script the correct amount, *you will lose funds*.
///
/// # Zero-reserve
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we be setting the option_zero_reserve feature in lightning/bolts#1140?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed offline, I'll hold off on signaling for now pending further spec discussions

@tankyleo tankyleo requested a review from carlaKC March 13, 2026 12:50
@ldk-claude-review-bot
Copy link
Collaborator

ldk-claude-review-bot commented Mar 14, 2026

@ldk-reviews-bot
Copy link

🔔 1st Reminder

Hey @TheBlueMatt @carlaKC! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link

🔔 5th Reminder

Hey @TheBlueMatt @carlaKC! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

Copy link
Contributor

@carlaKC carlaKC left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went through the tx_builder changes together IRL, those lgtm.

Just one q on v2 and a few comments on breaking up the tests for readability.

}
}

pub fn handle_and_accept_open_zero_reserve_channel(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(first time I posted this I think it got lost in the depths of GH).

Can we live without some of these helpers, because this accept step is the only one where we need to take a custom action?

Will add a few lines in do_test_accept_inbound_channel_from_trusted_peer_0reserve but the others are unaffected?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done below thank you

Comment on lines +2787 to +2801
// We can't afford the fee for an additional non-dust HTLC + the fee spike HTLC, so we can only send
// dust HTLCs...
// We don't bother to add the second stage tx fees, these would only make this min bigger
let min_nondust_htlc_sat = dust_limit_satoshis;
assert!(
channel_value_sat
- commit_tx_fee_sat(spike_multiple * feerate_per_kw, 2, &channel_type)
< min_nondust_htlc_sat
);
// But sending a big (not biggest) dust HTLC trims our balance output!
let max_dust_htlc = dust_limit_satoshis - 1;
assert!(
channel_value_sat - commit_tx_fee_sat(feerate_per_kw, 0, &channel_type) - max_dust_htlc
< dust_limit_satoshis
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we pull these asserts out of the channel type branching? If we use the correct fee rate / anchor values for each different type (eg, zero for 0FC) then they're the same?

Copy link
Contributor Author

@tankyleo tankyleo Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the fixups below, let me know if the test now reads better. I made some overall cleanups to help with readability.

Comment on lines +2821 to +2827
let sender_amount_msat = (channel_value_sat - min_value_sat) * 1000;
let details_0 = &nodes[0].node.list_channels()[0];
assert_eq!(details_0.next_outbound_htlc_minimum_msat, 1000);
assert_eq!(details_0.next_outbound_htlc_limit_msat, sender_amount_msat);
assert!(
details_0.next_outbound_htlc_limit_msat > details_0.next_outbound_htlc_minimum_msat
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here I think? Asserts are common for sender_amount_msat, we may just sometimes have zero anchors/fees somtimes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the fixups below, as described above, I made a bigger reorg of the tests.

Comment on lines +14068 to +14072
// TODO(zero_reserve): support reading and writing the `disable_channel_reserve` field
let counterparty_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis(
channel_value_satoshis, msg.common_fields.dust_limit_satoshis);
let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis(
channel_value_satoshis, MIN_CHAN_DUST_LIMIT_SATOSHIS);
let holder_selected_channel_reserve_satoshis = get_v2_channel_reserve_satoshis(
channel_value_satoshis, msg.common_fields.dust_limit_satoshis);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've switched the dust limits used to floor channel reserve here - I think the counterparty's reserve should use the common_fields.dust_limit_satoshi and we should use MIN_CHAN_DUST_LIMIT_SATOSHIS for holder?

Copy link
Contributor Author

@tankyleo tankyleo Mar 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've switched the dust limits used to floor channel reserve here

Yes claude's review has been screaming at me asking for a separate commit, will do this if you are good too.

I think the counterparty's reserve should use the common_fields.dust_limit_satoshis

The counterparty's reserve / holder_selected_channel_reserve_satoshis, should not be smaller than the counterparty's dust limit, msg.common_fields.dust_limit_satoshis here.

we should use MIN_CHAN_DUST_LIMIT_SATOSHIS for holder?

The holder's reserve / counterparty_selected_channel_reserve_satoshis, should not me smaller than the holder's dust limit, MIN_CHAN_DUST_LIMIT_SATOSHIS here.

Let me know if we agree, I believe the current diff is correct.

{
// If this dust HTLC produces no outputs, then we have to say something! It is now possible to produce a
// commitment with no outputs.
if !has_output(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice changes here 👌

nodes[1].node.list_channels()[0].next_outbound_htlc_limit_msat,
sender_amount_msat
);
send_payment(&nodes[1], &[&nodes[0]], sender_amount_msat);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PaymentSucceeds is the only test case that's common to all of our channel types, and FailsReceiverUpdateAddHTLC has its own branch within the failure case here.

Wondering if these can be separated into different handlers with common setup to improve readability? Would also save our needing to check the channel type / NoOutputs combination above - just assert that ReceiverCanAcceptA/B is only running on static_remote_key, for example.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See the fixups below, these would all get squashed into the test commit.

@TheBlueMatt TheBlueMatt removed their request for review March 16, 2026 20:42
@tankyleo tankyleo requested a review from carlaKC March 18, 2026 06:01
@tankyleo tankyleo force-pushed the 2026-02-zero-reserve branch from 814f540 to dee2218 Compare March 18, 2026 06:36
Comment on lines +2789 to +2808
let counterparty_selected_channel_reserve_satoshis = if prev_funding
.counterparty_selected_channel_reserve_satoshis
.expect("counterparty reserve is set")
== 0
{
// If we previously had a 0-value reserve, continue with the same reserve
0
} else {
get_v2_channel_reserve_satoshis(post_channel_value, MIN_CHAN_DUST_LIMIT_SATOSHIS)
};
let holder_selected_channel_reserve_satoshis =
if prev_funding.holder_selected_channel_reserve_satoshis == 0 {
// If the counterparty previously had a 0-value reserve, continue with the same reserve
0
} else {
get_v2_channel_reserve_satoshis(
post_channel_value,
context.counterparty_dust_limit_satoshis,
)
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: FundingScope::new_for_splice preserves zero reserves from the previous funding, but validate_splice_contributions (line 12303) always recalculates reserves using get_v2_channel_reserve_satoshis(post_channel_value, ...), which never returns 0. This means splice validation is stricter than the actual reserve for zero-reserve channels.

Concrete scenario: if a zero-reserve channel party has a post-splice balance less than the standard v2 reserve (e.g., 500 sats when the v2 reserve calculates to 546+), the splice will be rejected at validation time, even though the actual post-splice FundingScope would have reserve=0 and the splice would be valid.

validate_splice_contributions should check the previous funding's reserve values and propagate zero reserves the same way new_for_splice does.

Comment on lines +10839 to +10840
user_channel_id: u128, accept_0conf: bool, accept_0reserve: bool,
config_overrides: Option<ChannelConfigOverrides>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Two adjacent bool parameters (accept_0conf, accept_0reserve) are easy to swap accidentally at call sites. Consider a small builder/options struct or at minimum adding // accept_0conf, accept_0reserve comments at every call site. Several existing call sites (e.g., chanmon_update_fail_tests.rs:3239) already read 0, true, false, None which is not self-documenting.

&self, temporary_channel_id: &ChannelId, counterparty_node_id: &PublicKey,
user_channel_id: u128, config_overrides: Option<ChannelConfigOverrides>,
user_channel_id: u128, accept_0conf: bool, accept_0reserve: bool,
config_overrides: Option<ChannelConfigOverrides>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: accept_0reserve is silently ignored for V2/dual-funded channels. When the code path at the V2 branch (around line 10921) calls PendingV2Channel::new_inbound, it doesn't pass accept_0reserve — that function hardcodes is_0reserve = false (channel.rs line 14373-14375).

A user calling accept_inbound_channel_from_trusted_peer(id, node, 0, false, /*accept_0reserve=*/true, None) on a V2 channel open will believe they've set up zero-reserve, but the channel will be created with a normal 1% reserve.

Either:

  • Pass accept_0reserve through to PendingV2Channel::new_inbound and wire it into the reserve calculation, or
  • Return an APIError::APIMisuseError when accept_0reserve == true and the channel is V2, so the caller knows their intent was not honored.

Comment on lines 1147 to +1162
random_bytes
.copy_from_slice(&$dest_keys_manager.get_secure_random_bytes()[..16]);
let user_channel_id = u128::from_be_bytes(random_bytes);
$dest
.accept_inbound_channel(
temporary_channel_id,
counterparty_node_id,
user_channel_id,
None,
)
.unwrap();
if $trusted_accept {
$dest
.accept_inbound_channel_from_trusted_peer(
temporary_channel_id,
counterparty_node_id,
user_channel_id,
false,
true,
None,
)
.unwrap();
} else {
$dest
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The $trusted_accept branch calls accept_inbound_channel_from_trusted_peer with (accept_0conf=false, accept_0reserve=true), which means the zero-reserve acceptance path never exercises zero-conf. Consider also fuzzing the combination (true, true) on at least one channel to cover the zero-conf + zero-reserve interaction path that test_zero_reserve_zero_conf_combined tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

leaving this to a follow-up to this PR

@tankyleo tankyleo force-pushed the 2026-02-zero-reserve branch from cde4d01 to f56ae64 Compare March 19, 2026 02:02
channel_value_satoshis: u64, push_msat: u64, user_id: u128, config: &UserConfig,
current_chain_height: u32, outbound_scid_alias: u64,
temporary_channel_id: Option<ChannelId>, logger: L,
trusted_channel_features: Option<TrustedChannelFeatures>,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: The signature change from bool to Option<TrustedChannelFeatures> here was not propagated to the two #[cfg(ldk_test_vectors)] test functions that call OutboundV1Channel::new:

  1. outbound_commitment_test() — currently passes false (around line 17032)
  2. zero_fee_commitment_test_vectors() — currently passes false (around line 17757)

Both need to be updated to pass None instead of false. This won't be caught in normal CI since the tests are gated on #[cfg(ldk_test_vectors)], but will cause a compilation failure when running test vectors.

The goal is to prevent any commitments with no outputs, since these are
not broadcastable.
This new flag sets 0-reserve for the channel opener.
This new method sets 0-reserve for the channel accepter.
`ChannelContext::do_accept_channel_checks`,
`ChannelContext::new_for_outbound_channel`,
`ChannelContext::new_for_inbound_channel`,
`InboundV1Channel::new`,
`OutboundV1Channel::new`.
@tankyleo tankyleo force-pushed the 2026-02-zero-reserve branch from f56ae64 to 3542a15 Compare March 19, 2026 08:38
@tankyleo
Copy link
Contributor Author

Highlights from previous review:

  • rewrite tests for readability.
  • add is_0reserve: bool parameter to get_holder_selected_channel_reserve_satoshis and get_v2_channel_reserve_satoshis to prevent forgetting to handle 0-reserve channels.
  • exercise force-closes with a commitment that only has a single output, itself P2A.
  • use an enum to represent 0-conf, 0-reserve features instead of bools to guard against mistakes.
  • add zero-reserve to the internal API of V2 channels.

The branch with fixups for these items is here https://github.com/tankyleo/rust-lightning/releases/tag/2026-03-19-zero-reserve-fixups

Comment on lines +867 to +871
pub enum ChanType {
Legacy,
KeyedAnchors,
ZeroFeeCommitments,
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Consider adding #[derive(Clone, Copy)] (or at least Copy) on this fieldless enum. While Rust allows matching without Copy on fieldless enums (it reads only the discriminant), having Copy makes the intent explicit and avoids confusion for future maintainers. It also enables patterns like let ct = chan_type; if ever needed.

Comment on lines 3574 to +3578
fn new_for_inbound_channel<'a, ES: EntropySource, F: FeeEstimator, L: Logger>(
fee_estimator: &'a LowerBoundedFeeEstimator<F>,
entropy_source: &'a ES,
signer_provider: &'a SP,
counterparty_node_id: PublicKey,
their_features: &'a InitFeatures,
user_id: u128,
config: &'a UserConfig,
current_chain_height: u32,
logger: &'a L,
is_0conf: bool,
our_funding_satoshis: u64,
counterparty_pubkeys: ChannelPublicKeys,
channel_type: ChannelTypeFeatures,
holder_selected_channel_reserve_satoshis: u64,
msg_channel_reserve_satoshis: u64,
msg_push_msat: u64,
open_channel_fields: msgs::CommonOpenChannelFields,
fee_estimator: &'a LowerBoundedFeeEstimator<F>, entropy_source: &'a ES,
signer_provider: &'a SP, counterparty_node_id: PublicKey, their_features: &'a InitFeatures,
user_id: u128, config: &'a UserConfig, current_chain_height: u32, logger: &'a L,
trusted_channel_features: Option<TrustedChannelFeatures>, our_funding_satoshis: u64,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: The #[rustfmt::skip] removal on new_for_inbound_channel and new_for_outbound_channel reformats ~500 lines in the diff, burying the actual semantic changes (the trusted_channel_features parameter, zero-reserve validation relaxation, and minimum_depth logic). Consider splitting the formatting into a separate commit (the last commit message mentions formatting create_channel_internal but not these two functions).

config.reject_inbound_splices = false;
if !anchors {
config.channel_handshake_config.negotiate_anchors_zero_fee_htlc_tx = false;
match chan_type {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it is good to build out this way of increasing coverage. If chan type is part of the fuzz bytes, the fuzzer is able to zoom in on type-specific code paths.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for taking a look ! see the commit below

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good

@tankyleo tankyleo requested a review from joostjager March 19, 2026 16:35
Comment on lines 2848 to +2851
}

pub fn chanmon_consistency_test<Out: Output + MaybeSend + MaybeSync>(data: &[u8], out: Out) {
do_test(data, out.clone(), false);
do_test(data, out, true);
do_test(data, out);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous code ran do_test twice per fuzz input — once with anchors=false, once with anchors=true — guaranteeing every corpus entry exercised both channel type families. The new code runs do_test only once, with the channel type extracted from the fuzz input byte.

This means existing fuzz corpus entries that were evolved to cover interesting states under the two-pass model now only exercise a single channel type (determined by whatever happens to be in bits 3-4 of byte 0). Corpus entries that were valuable because they triggered anchor-specific behaviour may now be routed to the Legacy path or vice-versa, effectively discarding coverage.

Consider keeping the multi-pass structure by iterating over ChanType variants, or at minimum re-minimizing the corpus after this change.

override_config,
Some(TrustedChannelFeatures::ZeroReserve),
)
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_channel_to_trusted_peer_0reserve hardcodes TrustedChannelFeatures::ZeroReserve, meaning there's no API for opening an outbound channel with both zero-conf and zero-reserve. The accept side has ZeroConfZeroReserve, but the open side has no counterpart.

This asymmetry means a user who wants to create a zero-reserve channel and also trust the counterparty for zero-conf funding (e.g., a mutual trust scenario) can't do so from the opener side. They'd need to call create_channel_internal directly, which is private.

Consider either:

  • Adding a trusted_channel_features parameter to create_channel_to_trusted_peer_0reserve, or
  • Adding a separate create_channel_to_trusted_peer that takes TrustedChannelFeatures directly (analogous to the accept side's API).

@joostjager joostjager removed their request for review March 19, 2026 17:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Goal: Merge

Development

Successfully merging this pull request may close these issues.

Support 0 counterparty channel reserve

6 participants